SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
4.2 KB · 98 lines typescript
Raw Blame History
1/**2 * Same-origin proxy for the admin console: /admin/api/<path> → API /api/admin/<path> (or /api/v1/<path> when the3 * first segment is `v1`). Adds `x-dci-admin-token` from the httpOnly cookie server-side, forwards method / query /4 * body, streams the upstream body back (raw document bodies included). Mutations require a same-origin5 * `Sec-Fetch-Site` (or a matching `Origin`) — CSRF guard on top of the SameSite cookie.6 */7import { NextResponse, type NextRequest } from "next/server";8import { ADMIN_COOKIE, adminApiBase } from "@/lib/admin-api";910export const dynamic = "force-dynamic";11export const maxDuration = 300;1213const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;14const UPSTREAM_TIMEOUT_MS = 290_000;15const PASS_RESPONSE_HEADERS = ["content-type", "content-disposition", "content-length", "x-storage-key", "x-truncated", "x-cache", "etag", "x-ratelimit-limit", "x-ratelimit-remaining"];1617function json(status: number, body: Record<string, unknown>): NextResponse {18  const res = NextResponse.json(body, { status });19  res.headers.set("cache-control", "no-store");20  res.headers.set("x-robots-tag", "noindex, nofollow");21  return res;22}2324function sameOrigin(req: NextRequest): boolean {25  const sfs = req.headers.get("sec-fetch-site");26  if (sfs) return sfs === "same-origin";27  const origin = req.headers.get("origin");28  if (!origin) return false;29  try {30    const o = new URL(origin);31    const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? req.nextUrl.host;32    return o.host === host;33  } catch {34    return false;35  }36}3738async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }): Promise<Response> {39  const token = req.cookies.get(ADMIN_COOKIE)?.value;40  if (!token) return json(401, { error: "unauthorized", statusCode: 401 });4142  const { path } = await ctx.params;43  if (!Array.isArray(path) || path.length === 0) return json(404, { error: "not found", statusCode: 404 });44  for (const seg of path) if (!SEGMENT.test(seg)) return json(400, { error: "invalid path", statusCode: 400 });4546  const method = req.method.toUpperCase();47  const mutation = method !== "GET" && method !== "HEAD";48  if (mutation && !sameOrigin(req)) return json(403, { error: "cross-site request blocked", statusCode: 403 });4950  const [first, ...rest] = path;51  const upstreamPath = first === "v1" ? `/api/v1/${rest.map(encodeURIComponent).join("/")}` : `/api/admin/${path.map(encodeURIComponent).join("/")}`;52  const url = `${adminApiBase()}${upstreamPath}${req.nextUrl.search}`;5354  const headers = new Headers();55  headers.set("x-dci-admin-token", token);56  headers.set("accept", req.headers.get("accept") ?? "application/json");57  const ct = req.headers.get("content-type");58  if (ct) headers.set("content-type", ct);59  const inm = req.headers.get("if-none-match");60  if (inm) headers.set("if-none-match", inm);61  headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") ?? "127.0.0.1");62  headers.set("user-agent", "dci-web-admin-proxy/1.0");6364  const init: RequestInit & { duplex?: "half" } = {65    method,66    headers,67    redirect: "manual",68    cache: "no-store",69    signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),70  };71  if (mutation && req.body) {72    init.body = req.body;73    init.duplex = "half";74  }7576  let upstream: Response;77  try {78    upstream = await fetch(url, init);79  } catch (e) {80    const timeout = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");81    return json(timeout ? 504 : 502, { error: timeout ? "upstream timeout" : `upstream unreachable: ${e instanceof Error ? e.message : "error"}`, statusCode: timeout ? 504 : 502 });82  }8384  const out = new Headers();85  for (const h of PASS_RESPONSE_HEADERS) {86    const v = upstream.headers.get(h);87    if (v) out.set(h, v);88  }89  out.set("cache-control", "no-store");90  out.set("x-robots-tag", "noindex, nofollow");91  out.set("x-content-type-options", "nosniff");92  // never let an HTML body render in the top frame from this origin93  if ((out.get("content-type") ?? "").includes("text/html")) out.set("content-security-policy", "sandbox; default-src 'none'");94  return new Response(method === "HEAD" ? null : upstream.body, { status: upstream.status, headers: out });95}9697export { proxy as GET, proxy as POST, proxy as PATCH, proxy as PUT, proxy as DELETE, proxy as HEAD };98